Skip to content

Support intents for ui extensions#7134

Open
vividviolet wants to merge 3 commits intomainfrom
03-31-support_intents_for_ui_extensions
Open

Support intents for ui extensions#7134
vividviolet wants to merge 3 commits intomainfrom
03-31-support_intents_for_ui_extensions

Conversation

@vividviolet
Copy link
Copy Markdown
Member

@vividviolet vividviolet commented Mar 31, 2026

WHY are these changes introduced?

This change modernizes the UI extension asset handling system by replacing the legacy build_manifest approach with a new manifest.json-based system that supports additional asset types including intents.

Related https://github.com/shop/issues-admin-extensibility/issues/2274

WHAT is this pull request doing?

  • Adds support for intents field in extension point schemas with type, action, schema, name, and description properties
  • Adds Intents to the AssetIdentifier enum for asset handling
  • Updates UI extension build pipeline to use include_assets step instead of copy_static_assets, with support for tools, instructions, and intent schemas
  • Refactors extension payload generation to read from manifest.json instead of build_manifest, enabling better asset tracking with source file timestamps
  • Implements specialized handling for intents assets that transforms them into extension point-level properties with schema asset references
  • Updates tests to use the new manifest.json approach and adds comprehensive test coverage for intents, tools, and instructions assets

How to test your changes?

Make sure you have the proper betas applied (see canvas)

  1. Create a conditional action extension shopify app generate --template conditional_admin_action
  2. Run pnpm shopify app dev and verify the the UI extension has assets (main.js, should_render.js, tools schema url and instructions.md) that are fetchable through the Dev Server. You should be able to preview it in Admin Web
  3. Make a change to the action extension's code a verify that live reload works in Admin Web
  4. Create an Admin Link intent extension shopify app generate --template admin_intent_link
  5. Run pnpm shopify app dev and verify the the Admin link extension has assets (main.js, tools schema url and instructions.md and intents schema URLs) fetchable through the Dev Server
  6. Make a change to one of the static files like the intent-schema.json or tools.json and verify that Admin Web receives an update even through the websocket

Measuring impact

How do we know this change was effective? Please choose one:

  • n/a - this doesn't need measurement, e.g. a linting rule or a bug-fix

Checklist

  • I've considered possible cross-platform impacts (Mac, Linux, Windows)
  • I've considered possible documentation changes

Copy link
Copy Markdown
Member Author

vividviolet commented Mar 31, 2026

@isaacroldan isaacroldan force-pushed the 03-31-support_intents_for_ui_extensions branch from c65984a to bf92dc4 Compare April 1, 2026 08:53
@isaacroldan isaacroldan force-pushed the add_manifest_to_include_files_step branch 2 times, most recently from 96d95ba to d8b0d4a Compare April 1, 2026 08:53
@isaacroldan isaacroldan force-pushed the 03-31-support_intents_for_ui_extensions branch 2 times, most recently from 6e09feb to 5c1ddf1 Compare April 1, 2026 09:06
@isaacroldan isaacroldan changed the base branch from add_manifest_to_include_files_step to graphite-base/7134 April 1, 2026 09:23
@isaacroldan isaacroldan force-pushed the 03-31-support_intents_for_ui_extensions branch from 5c1ddf1 to 06dee38 Compare April 1, 2026 09:26
@isaacroldan isaacroldan changed the base branch from graphite-base/7134 to add_manifest_to_include_files_step April 1, 2026 09:26
Base automatically changed from add_manifest_to_include_files_step to main April 1, 2026 10:00
@isaacroldan isaacroldan force-pushed the 03-31-support_intents_for_ui_extensions branch from 06dee38 to 40bf3e5 Compare April 1, 2026 10:11
@vividviolet vividviolet force-pushed the 03-31-support_intents_for_ui_extensions branch 4 times, most recently from 8cc6cc8 to cc438ef Compare April 8, 2026 02:06
Comment thread packages/app/src/cli/services/dev/app-events/app-event-watcher.ts Outdated
@vividviolet vividviolet force-pushed the 03-31-support_intents_for_ui_extensions branch 2 times, most recently from 91e9fca to a6d5ab9 Compare April 10, 2026 05:56
Comment thread packages/app/src/cli/models/extensions/specifications/ui_extension.ts Outdated
Comment thread packages/app/src/cli/services/dev/extension/server/middlewares.ts Outdated
Comment thread packages/app/src/cli/models/extensions/specifications/ui_extension.ts Outdated
@vividviolet vividviolet force-pushed the 03-31-support_intents_for_ui_extensions branch 6 times, most recently from 19ce420 to 5b58638 Compare April 15, 2026 03:03
})
// Try the extension's build output first (for compiled bundles), then fall back
// to the extension's source directory (for static assets like tools, instructions).
const buildPath = joinPath(extension.directory, extension.outputRelativePath)
Copy link
Copy Markdown
Member Author

@vividviolet vividviolet Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@isaacroldan this fixes the issue mentioned here. There was a regression where we were getting updates even when the assets fail to build because we were serving them from the .dev-bundle which gets freshly created even when the build fails but it should be using the local assets. When the build fails the old local assets should not be deleted and we should not get a new update event in the websocket

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, never mind. I double checked and it still sends an update event, something else is going on

// destination instead, it would pick up files accumulated from previous builds
// that may no longer exist in the source, inflating the file count and producing
// stale entries in the manifest's pathMap.
const sourceFiles = await glob(['**/*'], {cwd: fullPath, absolute: false})
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously this was looking at copied files which broke when including assets for the adminspecificaiton after I fixed the issue where files were getting a new name on every build in dev even when there was no collision.

Fixed this to match copy-by-pattern.ts by looking at the source files, not the files in the destination folder.

// destination instead, it would pick up files accumulated from previous builds
// that may no longer exist in the source, inflating the file count and producing
// stale entries in the manifest.
const sourceFiles = await glob(['**/*'], {cwd: sourcePath, absolute: false})
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, fixed this to match copy-by-pattern.ts by looking at the source files, not the files in the destination folder.

@vividviolet vividviolet force-pushed the 03-31-support_intents_for_ui_extensions branch from 5b58638 to 8549387 Compare April 15, 2026 03:20
@vividviolet vividviolet force-pushed the 03-31-support_intents_for_ui_extensions branch 3 times, most recently from 17e3ff5 to c2c3f95 Compare April 15, 2026 04:42
@vividviolet vividviolet marked this pull request as ready for review April 15, 2026 04:43
@vividviolet vividviolet requested a review from a team as a code owner April 15, 2026 04:43
@vividviolet vividviolet force-pushed the 03-31-support_intents_for_ui_extensions branch 2 times, most recently from 34150b4 to 5c1736d Compare April 15, 2026 15:25
Include built assets in the manifest.json
Allow serving static assets from the extensions directory
@vividviolet vividviolet force-pushed the 03-31-support_intents_for_ui_extensions branch 2 times, most recently from 8cfce51 to a65c60d Compare April 15, 2026 19:26
…ore copying over to the bundle

Remove copy_static_assets client step completely. It was doing nothing for specifications that were not ui_extension.
ui_extension now has its own steps for including static assets
@vividviolet vividviolet force-pushed the 03-31-support_intents_for_ui_extensions branch from a65c60d to de5e9d9 Compare April 15, 2026 20:08
readonly type: 'bundle_ui'
readonly generatesAssetsManifest?: boolean
readonly config?: Record<string, never>
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't generatesAssetsManifest be part of the config ?

* writes built asset entries (from build_manifest) into manifest.json so
* downstream steps can merge on top.
*/
export async function executeBundleUIStep(step: LifecycleStep, context: BuildContext): Promise<void> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can safely change this from LifecycleStep to BundleUIStep
So you don't need to check at runtime for the config fields.

* Reads the existing manifest if present and deep merges the new entries.
* This allows multiple build steps to contribute to the same manifest.
*/
export async function mergeManifestEntries(context: BuildContext, entries: {[key: string]: unknown}): Promise<void> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should have a more explicit name?
The name suggests an in memory merge, but it is updating the existing file (or creating it if doesn't exist).

createOrUpdateManifestFilemaybe?

Copy link
Copy Markdown
Contributor

@isaacroldan isaacroldan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


Review assisted by pair-review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Ref Line 96) 🐛 Bug: All three analysis passes flagged this: buildUIExtension now writes the main bundle to localOutputPath (the extension's own directory), but secondary assets at line 96 still write to dirname(extension.outputPath). When called through buildForBundle (which both dev and deploy use), extension.outputPath is mutated to the bundle directory. This creates a split:

  1. Main bundle → written to local dir
  2. should_render → written to bundle dir
  3. executeBundleUIStep copies local dir → bundle dir (only main transfers)
  4. Dev middleware checks local dir then source dir — should_render is in neither

This is entangled with the middleware change at middlewares.ts:83, which no longer falls back to the bundle directory. Fixing this line so all assets go to the local directory would make the middleware change correct. Without this fix, secondary compiled assets will 404 from the dev server.

Suggestion: Build secondary assets to the local directory, consistent with main:

Suggested change
outputPath: joinPath(dirname(localOutputPath), asset.outputFileName),

@@ -122,6 +126,7 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex
const duration = Math.round(performance.now() - startTime)
const sizeInfo = await formatBundleSize(extension.outputPath)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Bug: All three analysis passes agree (confidence 0.90): formatBundleSize(extension.outputPath) reads from the bundle directory, but the file was just written to localOutputPath. The copy to the bundle directory happens later in executeBundleUIStep. On the first build, the file doesn't exist at the bundle path yet, so formatBundleSize silently returns empty string (it catches errors). On rebuilds, it reads the previous build's file — reporting stale size data. No crash, but the build success message loses bundle size info.

Suggestion:

Suggested change
const sizeInfo = await formatBundleSize(extension.outputPath)
const sizeInfo = await formatBundleSize(localOutputPath)


const config = context.extension.configuration as Record<string, unknown>
const extensionPoints = config.extension_points
if (!Array.isArray(extensionPoints) || !extensionPoints.every((ep) => typeof ep === 'object' && ep?.build_manifest))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Improvement: The check extensionPoints.every(ep => ep?.build_manifest) is all-or-nothing: if even one extension point doesn't have build_manifest, manifest generation is skipped for ALL extension points — including those that do have it. Currently this works because UIExtensionSchema always generates build_manifest for every extension point. But the guard would silently drop manifest entries if a future configuration omits build_manifest for some targets.

Suggestion: A filter would be more robust than an all-or-nothing check:

Suggested change
if (!Array.isArray(extensionPoints) || !extensionPoints.every((ep) => typeof ep === 'object' && ep?.build_manifest))
if (!Array.isArray(extensionPoints)) return
const pointsWithManifest = extensionPoints.filter(
(ep): ep is ExtensionPointWithBuildManifest => typeof ep === 'object' && !!ep?.build_manifest,
)
const entries = extractBuiltAssetEntries(pointsWithManifest)

let existing: {[key: string]: unknown} = {}
if (await fileExists(manifestPath)) {
const content = await readFile(manifestPath)
existing = JSON.parse(content)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🐛 Bug: Same pattern as readBundleManifest above but with the opposite failure mode: readFile + JSON.parse at line 132-133 run without any error handling. If manifest.json is corrupted from a previous failed build, JSON.parse throws an unhandled error that crashes the build. Unlike readBundleManifest which silently swallows errors, this one lets them propagate — blocking all subsequent builds until the developer manually deletes the corrupted file.

Suggestion: Wrap in a try/catch that falls back to an empty manifest:

Suggested change
existing = JSON.parse(content)
if (await fileExists(manifestPath)) {
try {
const content = await readFile(manifestPath)
existing = JSON.parse(content)
} catch {
outputDebug(`Warning: could not parse existing manifest.json, starting fresh\n`, context.options.stdout)
}
} else {

extensionPoint as NewExtensionPointSchemaType & {build_manifest: BuildManifest},
...(await mapManifestAssetsToPayload(
manifestEntry,
extensionPoint as unknown as NewExtensionPointSchemaType,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 Code Style: The cast extensionPoint as unknown as NewExtensionPointSchemaType uses as unknown to bypass TypeScript's type checking entirely. Since DevNewExtensionPointSchema extends Omit<NewExtensionPointSchemaType, 'intents'>, the core fields already match. The as unknown escape hatch suppresses potentially useful type errors that would catch real incompatibilities if these types diverge.

Suggestion: Consider widening mapManifestAssetsToPayload's parameter type to accept DevNewExtensionPointSchema directly, or use a direct cast without the unknown intermediary.

const content = await readFile(manifestPath)
return JSON.parse(content)
// eslint-disable-next-line no-catch-all/no-catch-all
} catch {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Improvement: The catch block catches all errors including JSON.parse failures from corrupted or partially-written manifest.json files. A readFile ENOENT (file doesn't exist) should return null — that's expected when no manifest has been generated yet. But a SyntaxError from JSON.parse means the file exists but is corrupted (e.g., partial write during a concurrent rebuild race), and silently returning null hides that problem. The developer would see missing assets with no explanation of why.

Suggestion: Distinguish file-not-found from parse errors:

Suggested change
} catch {
try {
const manifestPath = joinPath(buildDirectory, 'manifest.json')
const content = await readFile(manifestPath)
return JSON.parse(content)
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error: unknown) {
if (error instanceof SyntaxError) {
throw new Error(`Invalid manifest.json in ${buildDirectory}: ${error.message}`)
}
return null

if (await fileExists(builtAssetPath)) {
return fileServerMiddleware(event, {filePath: builtAssetPath})
}
return fileServerMiddleware(event, {filePath: joinPath(extension.directory, assetPath)})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security: The new middleware fallback serves files from extension.directory (the developer's source) using assetPath from the router. A crafted request like /extensions/{id}/assets/../../.env could read arbitrary files from the project directory. getAppAssetsMiddleware in the same file (lines 144-147) already has proper path traversal validation using resolvePath and startsWith. The inconsistency is worth addressing — the previous middleware only served from the build output temp directory, which had narrower scope. Now that the source directory is exposed, the same protection pattern should apply. The blast radius is limited to the developer's own machine (local dev server), but it's a gap compared to the established pattern.

Suggestion: Add path traversal validation matching the existing pattern from getAppAssetsMiddleware:

Suggested change
return fileServerMiddleware(event, {filePath: joinPath(extension.directory, assetPath)})
// Try the build output directory first (for compiled assets like dist/handle.js),
// then fall back to the extension's source directory (for static assets like tools, instructions).
const builtAssetPath = joinPath(dirname(joinPath(extension.directory, extension.outputRelativePath)), assetPath)
if (await fileExists(builtAssetPath)) {
return fileServerMiddleware(event, {filePath: builtAssetPath})
}
const resolvedDir = resolvePath(extension.directory)
const resolvedAssetPath = resolvePath(extension.directory, assetPath)
if (!resolvedAssetPath.startsWith(resolvedDir)) {
return sendError(event, {statusCode: 403, statusMessage: 'Path traversal is not allowed'})
}
return fileServerMiddleware(event, {filePath: resolvedAssetPath})

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants